Lección 3: NumPy

Objetivos:

  1. Conocer y comprender las ventajas de la utilización de la librería Numpy y su desarrollo.
  2. Estudiar los elementos o módulos esenciales de Numpy.

1. Motivación.

Numerical Python, o NumPy por su abreviatura más frecuente es la lanza de batalla para la programación científica en Python puesto que presenta muchas ventajas en relación a la programación sobre Python encontrándose entre éstas rutinas para manipular grandes arreglos y matrices de datos numéricos. Algunas de los módulos contenidos en NumPy son:

  • Un objeto matriz n-dimensional ndarray más rápido y eficiente que el clásico array.
  • Funciones para realizar cálculos y operaciones con matrices.
  • Herramientas para la lectura y escritura de conjuntos de datos basados en matrices.
  • Operaciones del álgebra lineal, transformadas de Fourier, etc.
  • Elementos para conexión con otros lenguajes.

Para comprender las ventajas de NumPy es importante entender porque en realidad Python es tan lento, lo cual se fundamente esencialmente en los siguientes aspectos:

1.- Python es un lenguaje escrito de forma dinámica en vez estático lo que significa que al momento en el que el programa se ejecuta, el intérprete no conoce el tipo de variable que son definidas.

2.- Python es interpretado en vez de compilado puesto que un compilador inteligente puede detectar y optimizar operaciones repetidas o no finalizadas, lo cual puede resultar en mejoras de velocidad.

3.- El Modelo de objetos de Python puede provocar ineficiencia en el acceso a la memoria en contraste con un arreglo básico de NumPy que en su forma más simple está construído sobre un arreglo de C, es decir, como un puntero hacia la dirección de memoria de cada elemento. Por otro lado, una lista de Python tiene un puntero hacia un bufer continuguo de punteros, cada uno de los cuales apuntan a un objeto de Python.

Dado lo anterior, de manera natural surge la pregunta ¿porqué entonces usar Python? y su principal ventaja corresponde justamente al hecho de que Python posee un tipeo dinámico lo que lo hace más fácil de usar que C siendo extremadamente flexible mejorando la eficientia de desarrollo de código en aquellas.

1.1. Ventajas de NumPy

La siguiente pregunta natural entonces es: ¿Por qué NumPy es tan rápido?. La respuesta a lo anterior se fundamente esencialmente en las siguientes razones:

  • Un arreglo de NumPy está descrito a través de metadata (numéro de dimensión, largo, tipo de datos, etc) y data (almacenada en bloques de memoria homogéneos y contiguos).
  • Los arreglos computacionales puede ser escritos muy eficientemente en lenguajes de bajo nivel como C, y de hecho, en gran parte NumPy lo está. Adicionalmente muchos métodos y funciones internas están enlazadas con famosas librerías como BLAS (Basic Linear Algebra Subprograms) y LAPACK (Linear Algebra Package).
  • La ubicación espacial en los patrones de acceso a la memoria resultan en significativas ganancias de rendimiento debido al cache de la CPU.
  • NumPy permite eliminar bucles y lineas de código a través del uso del cálculo en paralelo para cada elemento del arreglo. (lo que usualmente se denomina vectorizar el código).

2. Operando con NumPy

2.1. Estructuras básicas de datos

La estructura básica de datos en NumPy es justamente el array , el que es representado por un conjunto homogéneo de datos: int, float (por defecto), etc. que se disponen de manera adyacente en memoria unidimensional. En el caso que se necesitasen más dimensiones, NumPy proprociona la posibilidad de acceder a los elementos a través de sus índices o bien emplear la estructura matrix la cual difiere esencialmente en la definición del producto de matrices.

Para la creación de un array se poseen los siguientes mecanismos:

  • A partir de una lista o tupla homogénea.
  • A través de una función que retorna un array con valores.
  • Leyendo datos desde un fichero.

Junto a lo anterior, la función asarray permite transformar un objeto dado en arreglo.


In [4]:
from numpy import *

Test = [3, 4, 2]
A = array(Test)
print(A)

B = asarray(A)
print(B)


[3 4 2]
[3 4 2]

De forma similar, para realizar la operación inversa, es decir, transformar arrays a listas, se puede utilizar la función tolist . a través de la sintaxis array.tolist().

Algunas funciones útiles para operar con arrays en NumPy son las siguientes:

1.- arange, el equivalente a range en Python, pero tiene como salida un array en vez de una lista y permite utilizar espaciados de tipo float

Ejemplo:


In [5]:
Primero = arange(3)
print(Primero)

Segundo = arange(1, 10, 0.5)
print(Segundo)


[0 1 2]
[ 1.   1.5  2.   2.5  3.   3.5  4.   4.5  5.   5.5  6.   6.5  7.   7.5  8.
  8.5  9.   9.5]

2.- La función linspace nos entrega un array con un número determinado de elementos espaciados homogéneamente que incluye los límites, a diferencia de arange.


In [10]:
Linea = linspace(1, 2, 10)
print(Linea)


[ 1.          1.11111111  1.22222222  1.33333333  1.44444444  1.55555556
  1.66666667  1.77777778  1.88888889  2.        ]

3.- ones Nos entrega un array con la cantidad de unos que se indique. con la opción de entregar una matriz de nos en el caso de entregar una tupla.


In [12]:
Erste = ones(3)
print(Erste)
Zweite = ones((2, 2))
print(Zweite)


[ 1.  1.  1.]
[[ 1.  1.]
 [ 1.  1.]]

4.- zeros entrega un array con ceros y opera de forma análoga al caso de ones.


In [14]:
Dritte = zeros((4, 3))
print(Dritte)


[[ 0.  0.  0.]
 [ 0.  0.  0.]
 [ 0.  0.  0.]
 [ 0.  0.  0.]]

tanto en el caso de ones como zeros, la función ones_like y zeroz_like permiten crear un arreglo de igual tamaño a otro arreglo.

5.- eye genera la matriz identidad por lo que se debe indicar tanto número de filas como de columnas por lo cual permite incluso la generación de matrices no cuadradas. Es posible personalizar la diagonal que se desea llenar a través del parámetro k que indica la posición de la diagonal que se desea operar teniendo en mente que la posición por defecto, 0, corresponde a la diagonal principal.


In [18]:
Erste = eye(4,3)
Zweite = eye(2,2)
Dritte = eye(4,3, k = -1)
Vierte = eye(4,3, k = 1)
print(Erste)
print(Zweite)
print(Dritte)
print(Vierte)


[[ 1.  0.  0.]
 [ 0.  1.  0.]
 [ 0.  0.  1.]
 [ 0.  0.  0.]]
[[ 1.  0.]
 [ 0.  1.]]
[[ 0.  0.  0.]
 [ 1.  0.  0.]
 [ 0.  1.  0.]
 [ 0.  0.  1.]]
[[ 0.  1.  0.]
 [ 0.  0.  1.]
 [ 0.  0.  0.]
 [ 0.  0.  0.]]

6.- copy nos permite replicar un array preexistente. Para ello es importante primero comprender que cuando se asigna una variable a un array preexistente no se copia el array sino que se crea otro nombre para el mismo:


In [5]:
x = array([4, 10, 3])
y = x
print(y)


[ 4 10  3]

En la práctica, se puede considerar tanto a x como a y como un objeto en la memoria que mira las características del array. Así, para copiar un array, podemos ocupar el comando copy para crear otro objeto que mire al array. Es importante destacar que copy entrega los valores del array en el momento en que se copia, por lo cual si se modifica el original no lo hará copy.

Ejemplo:


In [6]:
x = array([4, 10, 3])
y = copy(x)
x[0] = 20
print(x)
print(y)


[20 10  3]
[ 4 10  3]

7.- concatenate((a1, a2, ..., an)) nos permite concatenar o juntar arrays de una misma dimensión.


In [12]:
Erste = array([[2, 2], [4, 2]])
Zweite = array([[1, 1]])
concatenate((Erste, Zweite))


Out[12]:
array([[2, 2],
       [4, 2],
       [1, 1]])

8.- logspace(n, m, k) permite producir un array con un número determinado de términos logarítmicamente espaciados donde n representa la potencia primera potencia $10^n$, m > n, la segunda potencia $10^m$ y k el número de términos y al igual que linspace, logspace también incluye los extremos del intervalo.


In [13]:
Beispiel = logspace(1, 10, 6)
print(Beispiel)


[  1.00000000e+01   6.30957344e+02   3.98107171e+04   2.51188643e+06
   1.58489319e+08   1.00000000e+10]

8.- mgrid[rango_x, rango_y], ( donde rango = inicio : final : espaciado ) es una poderosa función que permite la creación de arrays usados en mallados. Como resultado, mgrid nos entrega un array n-dimensional donde m[0, :, :] corresponden a las coordenadas en el eje X y m[1,:,:] las coordenadas en y, etc.


In [14]:
Beispiel = mgrid[0:10:0.1, 0:10]
print(Beispiel)


[[[ 0.   0.   0.  ...,  0.   0.   0. ]
  [ 0.1  0.1  0.1 ...,  0.1  0.1  0.1]
  [ 0.2  0.2  0.2 ...,  0.2  0.2  0.2]
  ..., 
  [ 9.7  9.7  9.7 ...,  9.7  9.7  9.7]
  [ 9.8  9.8  9.8 ...,  9.8  9.8  9.8]
  [ 9.9  9.9  9.9 ...,  9.9  9.9  9.9]]

 [[ 0.   1.   2.  ...,  7.   8.   9. ]
  [ 0.   1.   2.  ...,  7.   8.   9. ]
  [ 0.   1.   2.  ...,  7.   8.   9. ]
  ..., 
  [ 0.   1.   2.  ...,  7.   8.   9. ]
  [ 0.   1.   2.  ...,  7.   8.   9. ]
  [ 0.   1.   2.  ...,  7.   8.   9. ]]]

A continuación se enuncian algunas otras funciones útiles de considerar para la manipulación de arrays con NumPy:

  • ndim nos entrega la dimensión.
  • size nos entrega el número de elementos.
  • transpose nos permite transponer el arreglo de forma análoga como ocurre con una matriz invirtiendo todas las dimensiones.

1.2. Operaciones con arrays

NumPy nos permite realizar operaciones clásicas con los arreglos tanto elemento a elemento como globales. Las más comunes dentro de la clase elemento a elemento son:

  1. Suma (+)
  2. Resta (-)
  3. Multiplicación (*)
  4. División (/)
  5. Potenciación (**)

Note que naturalmente se requiere que los arreglos posean las mismas dimensiones. En el caso de las funciones globales dentro de las más útiles es importante destacar:

  1. max retorna el máximo elemento contenido en el array.
  2. min retorna el mínimo elemento contenido en el array.
  3. sum retorna la suma de los elementos.
  4. prod retorna el producto de los elementos.
  5. mean retorna el promedio por fila de los elementos del array.
  6. std retorna la desviación estándar de los elementos contenidos en el array.
  7. cross realiza el producto vectorial bajo dimensiones adecuadas.
  8. outer retorna el producto externo de los vectores contenidos en un array.

1.3. Vectorización de funciones.

Considere la siguiente motivación: NumPy nos entrega la posibilidad de definir funciones que utilicen como datos de entrada arrays. Por ejemplo:


In [20]:
def Ruido(A):
    return A+3*sin(A)*log(A)

Beispiel = array([[2, 3, 4], [0.1, 0.2, 0.3], [10, 10, -1]])
Ruido(A)


Out[20]:
array([ 3.46510853,  0.8525469 ,  3.89083084])

Sin embargo, en ocasiones dichas funciones pueden arrojarnos errores puesto que no es posible invocarlas directamente con esta clase de objetos.


In [21]:
def Comparador(A):
    return A if A > 10 else -A

Beispiel = array([[2, 2, 10], [10, 4, 10]])
Comparador(A)


---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-21-01857a800a73> in <module>()
      3 
      4 Beispiel = array([[2, 2, 10], [10, 4, 10]])
----> 5 Comparador(A)

<ipython-input-21-01857a800a73> in Comparador(A)
      1 def Comparador(A):
----> 2     return A if A > 10 else -A
      3 
      4 Beispiel = array([[2, 2, 10], [10, 4, 10]])
      5 Comparador(A)

ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()

Lo anterior se debe a que se requiere primeramente vectorizar la función, es decir, realizar el proceso de transformación una implementación escalar a una implementación vectorial traduciéndose en el número de operaciones necesarias para operar con los elementos del array. Lo anterior se puede realizar a través de un decorador (una función que se invoca antes que nuestra función principal y la transforma). Así, usando @vectorize


In [23]:
@vectorize

def Comparador(A):
    return A if A > 10 else -A

Comparador(A)


Out[23]:
array([-3, -4, -2])

Además de lo anterior, el interés de vectorizar el código se encuentra en que internamente al realizar lo anterior se eliminan índices que deben ser iterados para la operación a través de bucles sin embargo, esto produce más gasto de memoria y a veces, requiere inclusive replantear y adaptar el código. Veamos a continuación un breve ejemplo de lo anterior:

Ejemplo:

Dada una matriz $\mathcal{Q}_{m \times n}$ con $m > n$ se desea encontrar un vector ortogonal al espacio generado por las columnas de $\mathcal{Q}$.


In [28]:
def ort_escalar(Q, v):
    m, n = Q.shape
    for j in range(n):
        v -= dot(Q[:,j], v)*Q[:,j]
    return v

def ort_vectorizado(Q, v):
    proyeccion = dot(Q.transpose(), v)
    v -= dot(Q, proyeccion)
    return v

In [29]:
m, n = 10000, 100
A = 10 * random.random((m, n))
Q, R = linalg.qr(A, mode = 'reduced')
del R

v = random.random(m)
v1 = v.copy()
v2 = v.copy()

In [30]:
%timeit ort_escalar(Q, v1)


100 loops, best of 3: 6.68 ms per loop

In [31]:
%timeit ort_vectorizado(Q, v2)


The slowest run took 109.98 times longer than the fastest. This could mean that an intermediate result is being cached.
1000 loops, best of 3: 1.01 ms per loop